Skip to content

jsc: replace MarkedArrayBuffer::from_bytes with an owning constructor#31986

Closed
robobun wants to merge 3 commits into
mainfrom
farm/13d1b51c/marked-array-buffer-ownership
Closed

jsc: replace MarkedArrayBuffer::from_bytes with an owning constructor#31986
robobun wants to merge 3 commits into
mainfrom
farm/13d1b51c/marked-array-buffer-ownership

Conversation

@robobun

@robobun robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

What

MarkedArrayBuffer::from_bytes(&mut [u8], JSType) (src/jsc/array_buffer.rs) was a safe, public constructor that stored a borrowed slice's pointer in a lifetime-less struct with owns_buffer: true. The safe destroy() then freed that pointer with the default allocator, so safe code could free a stack buffer:

let mut bytes = [0u8; 1];
let mut buffer = MarkedArrayBuffer::from_bytes(&mut bytes, JSType::Uint8Array);
buffer.destroy(); // mi_free / libc free on a stack address

to_js / to_node_buffer had the same problem deferred: they install MarkedArrayBuffer_deallocator over the stored pointer, so GC frees it later.

All in-tree callers upheld the contract manually (Box::leak / heap::into_raw a default-allocator Box<[u8]>, then reborrow), so there is no user-reachable corruption today; this closes the API-boundary soundness hole.

Fixes #31969

Fix

  • Replace MarkedArrayBuffer::from_bytes(&mut [u8]) with MarkedArrayBuffer::from_owned_bytes(Box<[u8]>, JSType), built on the existing ArrayBuffer::from_owned_bytes, so the ownership transfer is enforced by the type system. This is the same shape Make ArrayBuffer::from_bytes unsafe and add owning constructors #31174 proposed for this type (that PR also covers the separate ArrayBuffer::from_bytes hole, [Unsoundness] ArrayBuffer no-copy constructors trust raw backing pointers #31970, which this PR deliberately does not touch).
  • Route MarkedArrayBuffer::from_string through it.
  • Migrate every caller off the manual leak-and-reborrow dance: Terminal.rs, SubprocessPipeReader.rs, shell/subproc.rs, and node_fs.rs (x3, aliased as Buffer::from_bytes).
  • to_js / to_node_buffer now take &mut self and clear owns_buffer at the handoff, so a destroy() after the buffer has been given to JSC cannot free the allocation a second time (flagged by review). The Readable.rs struct-literal construction was migrated to from_owned_bytes for the same reason.
  • destroy() now skips the free when byte_len == 0: an empty Box<[u8]> has no backing allocation (its pointer is dangling), so freeing it with the default allocator was itself an invalid free. Same reasoning as the existing empty-case guard in JSValue::create_buffer_from_box.

No behavior change: the pointers handed to JSC and the installed deallocators are identical before and after (callers already passed into_boxed_slice() allocations).

Test

test/regression/marked-array-buffer-ownership-soundness.test.ts + companion fixture crate, following the compile-fail pattern from #31093: the fixture contains exactly the unsound snippet above, and the test asserts cargo check --locked rejects it (error[E0599]: no associated function or constant named 'from_bytes' found for struct 'MarkedArrayBuffer', exit 101). On the unfixed tree the fixture compiles cleanly and the test fails; if a safe borrowed-slice constructor is ever reintroduced, it fails again. Skips when cargo or the cppbind codegen output is unavailable (prebuilt-only CI runners). The fixture's Cargo.lock is committed and checked with --locked so resolution never floats.

Verification

  • Soundness test: fails on unfixed src/ (fixture compiles), passes with the fix (E0599).
  • Existing coverage over the migrated call sites, all passing under the ASAN debug build: test/js/bun/terminal/terminal.test.ts (87 pass), test/js/bun/spawn/spawnSync.test.ts (6 pass), test/js/node/fs/fs.test.ts -t readFile (20 pass) and -t readlink (4 pass).
  • Standalone-graph readFileSync Buffer path verified with a compiled executable reading an embedded file.
  • Empty-buffer paths (readFileSync of an empty file, multipart FormData with an empty file blob) verified under ASAN.
  • cargo clippy -p bun_jsc -p bun_runtime --no-deps clean.

MarkedArrayBuffer::from_bytes(&mut [u8]) was a safe public constructor
that stored a borrowed slice's pointer with owns_buffer: true, so safe
code could mark a stack or short-lived buffer as allocator-owned and
destroy() would later free it with the default allocator.

Replace it with from_owned_bytes(Box<[u8]>, JSType) so the ownership
transfer is enforced by the type system, route from_string through it,
and migrate the callers (Terminal.rs, SubprocessPipeReader.rs,
shell/subproc.rs, node_fs.rs x3) off their manual Box::leak /
heap::into_raw dances.

destroy() now skips the free for empty buffers: an empty Box<[u8]> has
no backing allocation, so freeing its dangling pointer with the default
allocator was itself an invalid free.

test/regression/marked-array-buffer-ownership-soundness.test.ts runs
cargo check over a fixture crate containing the unsound pattern and
asserts it no longer resolves.

Fixes #31969
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f39b1133-a0cc-43ad-aeb7-133efcf8f525

📥 Commits

Reviewing files that changed from the base of the PR and between d0b587c and d156042.

📒 Files selected for processing (5)
  • src/jsc/array_buffer.rs
  • src/runtime/api/bun/subprocess/Readable.rs
  • src/runtime/node/node_fs.rs
  • test/regression/marked-array-buffer-ownership-soundness-fixture/Cargo.toml
  • test/regression/marked-array-buffer-ownership-soundness.test.ts

Walkthrough

Replaces the unsafe borrowed-slice MarkedArrayBuffer::from_bytes API with from_owned_bytes(Box<[u8]>, JSType), migrates runtime callsites to transfer boxed ownership and avoid leaking 'static slices, and adds a compile-fail fixture + test that ensures the unsound pattern no longer type-checks.

Changes

ArrayBuffer Ownership Model

Layer / File(s) Summary
Core MarkedArrayBuffer API redesign
src/jsc/array_buffer.rs
Added MarkedArrayBuffer::from_owned_bytes(Box<[u8]>, JSType), removed the safe from_bytes(&mut [u8]), updated destroy() to avoid freeing empty/dangling pointers, changed to_node_buffer/to_js to take &mut self and clear owns_buffer before JS handoff, and updated from_string() to use the new constructor.
Runtime callsite migrations
src/runtime/api/bun/Terminal.rs, src/runtime/api/bun/subprocess/SubprocessPipeReader.rs, src/runtime/shell/subproc.rs, src/runtime/api/bun/subprocess/Readable.rs, src/runtime/node/node_fs.rs
Replaced patterns that leaked boxed slices as 'static (e.g., Box::leak, heap::into_raw) with into_boxed_slice() + MarkedArrayBuffer::from_owned_bytes(...).to_node_buffer(...) and adjusted minor pattern bindings (e.g., Readdir::Buffers(mut items)).
Compile-fail regression test
test/regression/marked-array-buffer-ownership-soundness-fixture/, test/regression/marked-array-buffer-ownership-soundness.test.ts
Added a detached Cargo fixture that attempts the now-unsound from_bytes(&mut [u8]) pattern and a TypeScript test that runs cargo check --locked on that fixture, asserting the compiler emits the expected error and exits nonzero.

Suggested reviewers

  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: replacing an unsafe API (MarkedArrayBuffer::from_bytes) with a safer owning constructor.
Description check ✅ Passed The description provides comprehensive coverage: what the problem is, how it was fixed, test approach, and verification results. Both required template sections are detailed.
Linked Issues check ✅ Passed All changes directly address #31969 requirements: unsafe from_bytes constructor removed, replaced with from_owned_bytes requiring Box<[u8]>, all callers migrated, and regression test added to prevent reintroduction.
Out of Scope Changes check ✅ Passed All changes are in scope: core API change (MarkedArrayBuffer), migration of all callers, destroy() safety improvement, and regression test. The PR explicitly notes it does not touch ArrayBuffer::from_bytes (#31970), staying narrowly focused.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Make ArrayBuffer::from_bytes unsafe and add owning constructors #31174 - Both replace MarkedArrayBuffer::from_bytes with an owning constructor (from_owned_bytes) across the same 5 files to fix the same soundness hole; jsc: replace MarkedArrayBuffer::from_bytes with an owning constructor #31986 description acknowledges it is "the same shape Make ArrayBuffer::from_bytes unsafe and add owning constructors #31174 proposed for this type"

🤖 Generated with Claude Code

@robobun

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

Known overlap, noted in the PR description. The relationship:

If #31174 gets rebased and lands first, this one can be closed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/array_buffer.rs (1)

953-963: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Enforce ownership invalidation after JS handoff to prevent double-free.

from_owned_bytes marks owns_buffer = true, but after to_js/to_node_buffer transfer ownership to JSC, this flag is still live. A safe sequence can still call destroy() and free the same allocation a second time later when JSC runs the installed deallocator.

Suggested direction
-pub fn to_node_buffer(&self, global: &JSGlobalObject) -> JSValue {
+pub fn to_node_buffer(&mut self, global: &JSGlobalObject) -> JSValue {
     let mut buf = self.buffer;
-    JSValue::create_buffer(global, buf.byte_slice_mut())
+    let out = JSValue::create_buffer(global, buf.byte_slice_mut());
+    self.owns_buffer = false;
+    out
 }
 
-pub fn to_js(&self, global: &JSGlobalObject) -> JsResult<JSValue> {
+pub fn to_js(&mut self, global: &JSGlobalObject) -> JsResult<JSValue> {
     ...
-    make_typed_array_with_bytes_no_copy(...)
+    let out = make_typed_array_with_bytes_no_copy(...)?;
+    self.owns_buffer = false;
+    self.buffer.value = out;
+    Ok(out)
 }

As per coding guidelines, every allocation must have exactly one named owner and be released exactly once.

Also applies to: 981-995

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/array_buffer.rs` around lines 953 - 963, from_owned_bytes currently
sets owns_buffer = true but never clears it after handing the buffer to JSC,
allowing a double-free via destroy(); fix by ensuring ownership is invalidated
when the buffer is transferred to JS: update the transfer paths (the methods
to_js and to_node_buffer) to consume or mutate the MarkedArrayBuffer so they set
owns_buffer = false (or take self by value) after installing the JSC
deallocator, and ensure destroy() checks owns_buffer before freeing; reference
the MarkedArrayBuffer type and the methods from_owned_bytes, to_js,
to_node_buffer, and destroy so the owner flag is cleared exactly once on
handoff.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/regression/marked-array-buffer-ownership-soundness.test.ts`:
- Line 38: Remove the explicit timeout override in the test invocation—delete
the "{ timeout: 10 * 60 * 1000 }" options object passed to the test (the call in
the marked-array-buffer-ownership-soundness test) so the test relies on Bun's
default timeout behavior; search for the test declaration/name in the file and
remove that options argument from the test call (leaving other options/arguments
intact).

---

Outside diff comments:
In `@src/jsc/array_buffer.rs`:
- Around line 953-963: from_owned_bytes currently sets owns_buffer = true but
never clears it after handing the buffer to JSC, allowing a double-free via
destroy(); fix by ensuring ownership is invalidated when the buffer is
transferred to JS: update the transfer paths (the methods to_js and
to_node_buffer) to consume or mutate the MarkedArrayBuffer so they set
owns_buffer = false (or take self by value) after installing the JSC
deallocator, and ensure destroy() checks owns_buffer before freeing; reference
the MarkedArrayBuffer type and the methods from_owned_bytes, to_js,
to_node_buffer, and destroy so the owner flag is cleared exactly once on
handoff.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a94c84f8-ec51-4784-b15f-69a307f207c6

📥 Commits

Reviewing files that changed from the base of the PR and between a988615 and d0b587c.

⛔ Files ignored due to path filters (1)
  • test/regression/marked-array-buffer-ownership-soundness-fixture/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • src/jsc/array_buffer.rs
  • src/runtime/api/bun/Terminal.rs
  • src/runtime/api/bun/subprocess/SubprocessPipeReader.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/shell/subproc.rs
  • test/regression/marked-array-buffer-ownership-soundness-fixture/.gitignore
  • test/regression/marked-array-buffer-ownership-soundness-fixture/Cargo.toml
  • test/regression/marked-array-buffer-ownership-soundness-fixture/src/lib.rs
  • test/regression/marked-array-buffer-ownership-soundness.test.ts

Comment thread test/regression/marked-array-buffer-ownership-soundness.test.ts
Comment thread test/regression/marked-array-buffer-ownership-soundness.test.ts
Comment thread src/jsc/array_buffer.rs
Review follow-ups:
- to_js/to_node_buffer now take &mut self and clear owns_buffer at the
  handoff, so a later destroy() cannot free the allocation JSC now owns.
- Migrate the Readable.rs struct-literal construction to
  MarkedArrayBuffer::from_owned_bytes so the destroy() SAFETY comment
  holds for every owns_buffer producer.
- Make the soundness test self-diagnosing when the fixture Cargo.lock
  goes stale, and document the regeneration command.
@robobun

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed CodeRabbit's out-of-band critical finding in d156042: to_js / to_node_buffer now take &mut self and clear owns_buffer at the handoff, so a destroy() after the buffer was given to JSC is a no-op instead of a double free. The flag is cleared before the fallible FFI call so an exception path can at worst leak, never double-free. Callers needing the mutable receiver (Readdir::Buffers, StringOrBuffer::to_js) were updated; all previously listed suites re-run green under the ASAN debug build.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — both earlier nits are addressed (Readable.rs now goes through from_owned_bytes, and the test surfaces a stale-lockfile hint). The added owns_buffer clearing in to_js/to_node_buffer looks right too, but since this is allocator-ownership code at the Rust↔JSC FFI boundary with a public &self&mut self signature change, I'd like a human to give it a final look.

Extended reasoning...

Overview

This PR replaces the unsound MarkedArrayBuffer::from_bytes(&mut [u8]) constructor with from_owned_bytes(Box<[u8]>), migrates six call sites (Terminal, subprocess pipe readers, shell subproc, three node_fs paths), guards destroy() against freeing an empty box's dangling pointer, and adds a compile-fail regression fixture + test. Since my previous review, a new commit also changed to_js/to_node_buffer to take &mut self and clear owns_buffer before handing the buffer to JSC, and migrated the Readable.rs struct-literal site I'd flagged.

Prior feedback

Both of my earlier inline comments were addressed:

  • Readable.rs now calls MarkedArrayBuffer::from_owned_bytes(own, …) instead of constructing the struct literal with owns_buffer: true, so the SAFETY comment in destroy() is now accurate.
  • The soundness test now detects --locked was passed in cargo's output and throws with the regeneration command plus full stderr, so a stale fixture lockfile is self-diagnosing.

Security risks

No injection/auth/data-exposure surface. The risk class here is memory safety: this code governs which allocator frees buffers that cross the Rust↔JSC boundary, and the new owns_buffer = false clearing in to_js/to_node_buffer changes when destroy() becomes a no-op. A mistake here is a double-free or invalid-free in production. The reasoning in the diff (clear before the fallible FFI call; leak-on-throw is the safe direction) looks correct, and the empty-box guard in destroy() matches the existing pattern in JSValue::create_buffer_from_box.

Level of scrutiny

High. This is core JSC FFI ownership logic, not config or test-only code. The &self&mut self signature change on to_js/to_node_buffer is a public API change that required updating every caller (e.g. Readdir::Buffers now iterates with iter_mut()). The migrated call sites are mechanically simpler than before (no more heap::into_raw + unsafe { &mut *raw }), which is good, but the overall change deserves a maintainer's eyes.

Other factors

  • There is a known overlapping PR (#31174) covering the same constructor; the description and a robobun comment explain the relationship, but a maintainer should decide which lands.
  • CI shows test/cli/install/bunx.test.ts failing across all platforms on the latest build; this looks unrelated to the change (it's a bunx install test) but is worth confirming before merge.
  • ASAN verification was run per the description over the migrated paths (terminal, spawnSync, fs readFile/readlink, empty-buffer cases).

@robobun

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI analysis for build 61379 (final state): every failure is pre-existing or external, none touch this diff.

  • test/cli/install/bunx.test.ts:518 ("should handle package that requires node 24"): runs bun x --bun @angular/cli@latest against the live registry; fails on all 7 red lanes and on unrelated PRs. PR test: pin @angular/cli version in bunx node-version test #31820 exists specifically to pin this version.
  • test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts ("error snapshots", windows lanes): reproduces identically on the unmodified main-tip binary: USE_SYSTEM_BUN=1 bun test <file> with bun v1.4.0 (a988615) fails the same snapshot. Not introduced here.
  • test/js/bun/http/serve-body-leak.test.ts (debian-asan): RSS ended 1.4% over the 512MB threshold, passed on the automatic retry.
  • darwin-14-x64 exited -1 (agent-level failure, no test output).

The suites covering the paths this PR changes (terminal, spawnSync, fs readFile/readdir, shell, and the compile-fail soundness test) pass on every lane.

@alii

alii commented Jun 9, 2026

Copy link
Copy Markdown
Member

Superseded by the adopted/rebased copy — see replacement PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Unsoundness] MarkedArrayBuffer::from_bytes frees caller-owned slices

2 participants